﻿using System;
using System.Text;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.UI.Xaml;
using Windows.Storage.Streams;
using Windows.Web.Http;
using Windows.Web.Http.Filters;
using Windows.Web.Http.Headers;

namespace MJPEGDecoderWinRTLib
{
    public class MJPEGDecoder
    {
        // magic 2 byte header for JPEG images
        private readonly byte[] JpegHeader = new byte[] { 0xff, 0xd8 };
        // byte[] to match "Content-Length" header in boundary section.
        // The array only contains "ength:" because some devices have "Content-length:" as header
        private readonly byte[] ContentLengthMarker = new byte[] { 0x65, 0x6E, 0x67, 0x74, 0x68, 0x3A };
        private readonly byte[] crLF = new byte[] { 0x0D, 0x0A };

        // image detection mode
        public enum ImageDetectionMode
        {
            Auto = 0,
            ContentLengthBased = 1,
            BoundaryBased = 2
        }
        private ImageDetectionMode _imgDetectionMode = ImageDetectionMode.Auto;
        // chunk size for approach 2
        private uint _chunkSize = 2048;
        // chunk size for a boundary section
        private uint _boundaryChunkSize = 130; // Needs to be bigger when various headers are included
        // Typically: Content-Type and Content-Length

        private byte[] boundaryBytes;
        private bool _streamActive;

        // used to marshal back to UI thread
        private SynchronizationContext _context;

        // current encoded JPEG image
        public byte[] CurrentFrame { get; private set; }

        // event to get the buffer above handed over to the consumer object
        public event EventHandler<FrameReadyEventArgs> FrameReady;
        public event EventHandler<ErrorEventArgs> Error;

        public MJPEGDecoder()
        {
            _context = SynchronizationContext.Current;
        }

        #region Properties

        public ImageDetectionMode ImgDetectionMode
        {
            get { return _imgDetectionMode; }
            set { _imgDetectionMode = value; }
        }
        public uint ChunkSize
        {
            get { return _chunkSize; }
            set { _chunkSize = value; }
        }
        public uint BoundaryChunkSize
        {
            get { return _boundaryChunkSize; }
            set { _boundaryChunkSize = value; }
        }

        #endregion

        /// <summary>
        /// Establish a http connection with an MJPEG streaming device, receive MJPEG
        /// image data and pass the received frames to a listener.
        /// </summary>
        /// 
        /// 3 configurable approaches are supported to extract the image from the stream:
        ///
        /// ImageDetectionMode.ContentLengthBased
        /// -------------------------------------
        /// We assume a boundary section that has a Content-Length attribute.
        /// The content length value is used to tell the http input stream to read the full
        /// image data. This approach is the fastest, requires the least processing and
        /// consequently has the best fps potential. 
        /// A MJPEG image boundary section typically looks like this:
        ///
        ///  --myboundary
        ///  Content-Type: image/jpeg
        ///  Content-Length: 64199
        ///  \r\n
        ///  
        /// The MJPEG image data folows the boundary section and always starts with the
        /// bytes 0xff, 0xd8.
        ///
        ///  Notes: I have seen some variants with different MJPEG IP camera's
        ///   - 'Content-Length' is 'Content-length' on a D-Link
        ///   - Some camera's add a 'Date' mime attachment header
        ///   - Some devices have \r\n bytes before --myboundary.
        ///
        /// ImageDetectionMode.BoundaryBased
        /// --------------------------------
        /// We still assume of course a boundary section but will isolate the image data
        /// by searching the MJPEG start bytes (0xff, 0xd8) and then treating all data as
        /// image data until we reach the next boundary marker.
        /// Since we don't know the size of the image we will have to read from the input
        /// stream in chunks. The size of the chunk is problematic when reaching for the
        /// highest fps. Small chunks will inevitably lead to lower fps. Big chunk sizes
        /// will give better fps (when moved up to for instance half of the frame size).
        /// But, if you have to handle multiple type of devices in one app, with various resolutions
        /// and quality settings, then it will be difficult to define the optimal chunk size
        /// unless you can configure the size on a per device base. The chunk size is a read/write
        /// property in MJPEGDecoder. 
        /// 
        /// ImageDetectionMode.Auto
        /// -----------------------
        /// MJPEGDecoder will use one of the above modes based on the presence of a Content-Length
        /// header in the first boundary section that it receives.
        /// This is the default mode.
        /// 
        /// <param name="requestUri"></param>
        /// <param name="username"></param>
        /// <param name="password"></param>
        /// <returns></returns>
        public async Task ParseStreamAsync(string requestUri, string username = null, string password = null)
        {
            try
            {
                var filter = new HttpBaseProtocolFilter();
                if (!string.IsNullOrEmpty(username) || !string.IsNullOrEmpty(password))
                {
                    if (string.IsNullOrEmpty(username))
                    {
                        throw new Exception("You must provide a username");
                    }
                    if (string.IsNullOrEmpty(password))
                    {
                        throw new Exception("You must provide a password");
                    }
                    filter.ServerCredential = new Windows.Security.Credentials.PasswordCredential(requestUri, username, password);
                }
                // Establish HTTP connection to the requestUri
                HttpClient httpClient = new HttpClient(filter);
                HttpResponseMessage resp = await httpClient.GetAsync(new Uri(requestUri), HttpCompletionOption.ResponseHeadersRead);
                if (resp.StatusCode != Windows.Web.Http.HttpStatusCode.Ok)
                {
                    throw new Exception("IP device returned an error. Error: " + resp.StatusCode.ToString());
                }

                // Connection established, retrieve the boundary marker from the Content-Type
                // header in the connection response
                HttpContentHeaderCollection httpHeaders = resp.Content.Headers;
                // Note: must get Content-Type using Headers.TryGetValue because I get an exception when I use
                // resp.Content.Headers.ContentType.ToString() and the content type value contains a space character.
                string contentType = "";
                var c = httpHeaders.TryGetValue("Content-Type", out contentType);
                if (string.IsNullOrEmpty(contentType) || !contentType.Contains("="))
                {
                    throw new Exception("IP device returned a response that fails to indicate a proper MJPEG stream.");
                }
                string boundary = contentType.Split('=')[1].Replace("\"", "");
                // Store the boundary marker string in a byte[].
                boundaryBytes = Encoding.UTF8.GetBytes(boundary.StartsWith("--") ? boundary : "--" + boundary);

                IInputStream stream = await resp.Content.ReadAsInputStreamAsync();

                // Read boundary section in buffer (we are reading a bit more because we don't
                // know the size of the boundary section).
                IBuffer boundaryBuffer = new Windows.Storage.Streams.Buffer(_boundaryChunkSize);
                boundaryBuffer = await stream.ReadAsync(boundaryBuffer, boundaryBuffer.Capacity, InputStreamOptions.None);
                // Parse the boundary section
                uint contentLength = 0;
                int nextFindPos = ParseBoundarySection(boundaryBuffer, out contentLength);
                int boundarySectionNr = 1;

                // Check which image detection mode we must use
                ImageDetectionMode mode = _imgDetectionMode;
                if (mode == ImageDetectionMode.Auto)
                {
                    mode = (contentLength != 0) ? ImageDetectionMode.ContentLengthBased : ImageDetectionMode.BoundaryBased;
                }

                IBuffer buffer = new Windows.Storage.Streams.Buffer(_chunkSize);
                uint bufferCapacity = _chunkSize;
                if (mode == ImageDetectionMode.BoundaryBased)
                {
                    boundaryBuffer.CopyTo(0, buffer, 0, _boundaryChunkSize);
                    buffer.Length = _boundaryChunkSize;
                }
                byte[] imageBuffer = new byte[1024 * 1024];
                _streamActive = true;
                while (_streamActive)
                {
                    if (mode == ImageDetectionMode.ContentLengthBased)
                    {
                        // Mode: Read whole image using Content-Length

                        // Find the 2 byte JPEG header
                        int imageStart = boundaryBuffer.Find(JpegHeader, (uint)nextFindPos);
                        if (imageStart == -1)
                        {
                            throw new Exception("IP device doesn't stream JPEG images (anymore)");
                        }
                        // Copy the JPEG header and the already received JPEG image data from
                        // boundaryBuffer to the imageBuffer.
                        uint imgHandledSize = boundaryBuffer.Length - (uint)imageStart;
                        boundaryBuffer.CopyTo((uint)imageStart, imageBuffer, 0, (int)imgHandledSize);

                        // Re-allocate buffer if optimal capacity has changed
                        if (bufferCapacity < contentLength)
                        {
                            bufferCapacity = contentLength + 1600;
                            buffer = new Windows.Storage.Streams.Buffer(bufferCapacity);
                        }

                        // Receive the remainder of the image
                        uint imgRemainingSize = contentLength - imgHandledSize;
                        buffer = await stream.ReadAsync(buffer, imgRemainingSize, InputStreamOptions.None);
                        // Copy all received data to the imageBuffer
                        buffer.CopyTo(0, imageBuffer, (int)imgHandledSize, (int)imgRemainingSize);

                        // Pass frame to the listener
                        byte[] frame = new byte[contentLength];
                        Array.Copy(imageBuffer, 0, frame, 0, (int)contentLength);
                        ProcessFrame(frame);

                        // Read next boundary section (plus some initial image data) in buffer
                        boundaryBuffer = await stream.ReadAsync(boundaryBuffer, boundaryBuffer.Capacity, InputStreamOptions.None);
                        // Parse the boundary section
                        nextFindPos = ParseBoundarySection(boundaryBuffer, out contentLength);
                        boundarySectionNr++;
                    }
                    else
                    {
                        // Mode: Read image using multiple chunks of data and look for boundary

                        // Find the 2 byte JPEG header
                        int imageStart = buffer.Find(JpegHeader);
                        if (imageStart == -1)
                        {
                            throw new Exception("IP device doesn't stream JPEG images (anymore)");
                        }
                        // Copy the JPEG header and the already received JPEG image data from
                        // boundaryBuffer to the imageBuffer.
                        uint imgHandledSize = buffer.Length - (uint)imageStart;
                        buffer.CopyTo((uint)imageStart, imageBuffer, 0, (int)imgHandledSize);

                        // Keep on getting chunks of data until we find the boundary marker
                        while (true)
                        {
                            buffer = await stream.ReadAsync(buffer, buffer.Capacity, InputStreamOptions.None);

                            // Find possible boundary text in the received data
                            int imageEnd = buffer.Find(boundaryBytes);
                            if (imageEnd != -1)
                            {
                                // Copy the remainder of the JPEG (everything before the boundary bytes
                                // to the imageBuffer
                                buffer.CopyTo(0, imageBuffer, (int)imgHandledSize, imageEnd);
                                imgHandledSize += (uint)imageEnd;

                                // Pass frame to listener
                                byte[] frame = new byte[imgHandledSize];
                                Array.Copy(imageBuffer, 0, frame, 0, (int)imgHandledSize);
                                ProcessFrame(frame);

                                // Copy boundary bytes + all data behind the boundary bytes to the start
                                // of the buffer
                                buffer.CopyTo((uint)imageEnd, buffer, 0, buffer.Length - (uint)imageEnd);

                                // Fill the remainder of the buffer with new data and start over
                                if (imageEnd != 0)
                                {
                                    IBuffer temp = new Windows.Storage.Streams.Buffer((uint)imageEnd);
                                    temp = await stream.ReadAsync(temp, (uint)imageEnd, InputStreamOptions.None);
                                    if (temp.Length != 0)
                                    {
                                        temp.CopyTo(0, buffer, buffer.Length - (uint)imageEnd, temp.Length);
                                    }
                                }
                                break;
                            }
                            // Copy all of the image data to the imageBuffer
                            buffer.CopyTo(0, imageBuffer, (int)imgHandledSize, (int)buffer.Length);
                            imgHandledSize += buffer.Length;
                        }
                    }
                }
                stream.Dispose();
                resp.Dispose();
                httpClient.Dispose();
            }
            catch (Exception ex)
            {
                if (Error != null)
                {
                    _context.Post(delegate
                    {
                        string msg = ex.Message;
                        if (msg.Contains("0x80072EFD"))
                        {
                            msg = "Can't connect. The URL is probably wrong.";
                        }
                        Error(this, new ErrorEventArgs() { Message = msg });
                    }, null);
                }
            }
        }

        public void StopStream()
        {
            _streamActive = false;
        }

        /// <summary>
        /// Locate boundary section in IBuffer and retrieve the value of Content-Length
        /// </summary>
        /// <param name="boundaryBuffer"></param>
        /// <param name="contentLength">When not 0 then the method has written the content
        ///                             length in here.
        ///                             When 0 the content length could not be extracted</param>
        /// <returns></returns>
        private int ParseBoundarySection(IBuffer boundaryBuffer, out uint contentLength)
        {
            if (boundaryBuffer.Length == 0)
            {
                throw new Exception("IP device doesn't stream (anymore)");
            }
            int boundaryStart = boundaryBuffer.Find(boundaryBytes);
            if (boundaryStart == -1)
            {
                throw new Exception("Problem detecting boundary");
            }
            // find Content-Length indicator
            contentLength = 0;
            int nextFindPos = boundaryStart + boundaryBytes.Length;
            int lengthStartPos = boundaryBuffer.Find(ContentLengthMarker, (uint)nextFindPos);
            if (lengthStartPos != -1)
            {
                lengthStartPos += 6;
                nextFindPos = boundaryBuffer.Find(crLF, (uint)lengthStartPos);
                if (nextFindPos != -1)
                {
                    int lengthSize = nextFindPos - lengthStartPos;
                    byte[] lengthArray = new byte[lengthSize];
                    boundaryBuffer.CopyTo((uint)lengthStartPos, lengthArray, 0, lengthSize);
                    string lengthStr = System.Text.Encoding.UTF8.GetString(lengthArray, 0, lengthSize);
                    contentLength = Convert.ToUInt32(lengthStr);
                }
            }
            return nextFindPos;
        }

        private void ProcessFrame(byte[] frame)
        {
            CurrentFrame = frame;
            if (Application.Current != null)
            {
                // get it on the UI thread
                _context.Post(delegate
                {
                    // tell whoever's listening that we have a frame
                    if (FrameReady != null)
                        FrameReady(this, new FrameReadyEventArgs { FrameBuffer = CurrentFrame });
                }, null);
            }
        }
    }

    static class Extensions
    {
        public static int Find(this IBuffer buff, byte[] search, uint start = 0)
        {
            // enumerate the buffer but don't overstep the bounds
            for (; start < buff.Length - search.Length; start++)
            {
                // we found the first character
                if (buff.GetByte(start) == search[0])
                {
                    uint next;

                    // traverse the rest of the bytes
                    for (next = 1; next < search.Length; next++)
                    {
                        // if we don't match, bail
                        if (buff.GetByte(start + next) != search[next])
                            break;
                    }

                    if (next == search.Length)
                        return (int)start;
                }
            }
            // not found
            return -1;
        }
    }

    public class FrameReadyEventArgs : EventArgs
    {
        public byte[] FrameBuffer;
    }

    public sealed class ErrorEventArgs
    {
        public string Message { get; set; }
    }
}
